5장. Null Safety
프로그램을 짜다 보면
“값이 있어야 하는데 없는” 상황을 자주 만납니다.
회원을 찾았는데 그런 회원이 없거나,
입력값이 비어 있거나,
아직 값을 넣지 않은 변수가 그렇습니다.
이렇게 “값이 없음“을 나타내는 특별한 표시가
바로 null입니다.
null은 “아무 값도 없음“을 뜻합니다.
비어 있는 상자를 떠올리면 쉽습니다.
문제는 이 빈 상자를 열어서
안에 든 물건을 꺼내려 할 때 생깁니다.
상자가 비어 있는데 물건을 꺼내라고 하면
프로그램은 어쩔 줄 몰라 멈춰 버립니다.
자바에서는 이때
NullPointerException이라는 오류가 터집니다.
NullPointerException은
“널 포인터 예외“라고 읽고,
줄여서 NPE라고 부릅니다.
이 오류는 서버에서 가장 자주 터지는 오류 중 하나입니다.
심지어 이 오류를 만든 사람조차
“10억 달러짜리 실수“라고 후회했을 정도입니다.
자바에서 NPE가 무서운 이유는
프로그램을 실행해 봐야만 알 수 있기 때문입니다.
코드를 짤 때는 멀쩡해 보이다가,
실제 사용자가 쓰는 도중에 갑자기 터집니다.
코틀린은 이 문제를 정면으로 해결한 언어입니다.
null이 될 수 있는 값과 아닌 값을
타입 단계에서 아예 구분합니다.
그래서 대부분의 null 오류를
실행 전에, 즉 컴파일 단계에서 미리 잡아냅니다.
이 장에서 배우는 내용이
코틀린이 “안전한 언어“라고 불리는 가장 큰 이유입니다.
5.1 Nullable과 Non-null
String과 String?
코틀린에서는 타입을 두 종류로 나눕니다.
- null이 들어올 수 없는 타입
- null이 들어올 수 있는 타입
이 둘을 나누는 표시는 아주 간단합니다.
타입 뒤에 물음표(?)를 붙이느냐 마느냐입니다.
먼저 코드로 보겠습니다.
val name: String = "홍길동"
val nickname: String? = null
String: null이 들어올 수 없는 타입String?: null이 들어올 수 있는 타입
물음표가 없는 타입에는
절대 null을 넣을 수 없습니다.
val name: String = null // 오류: String에는 null을 넣을 수 없음
이 코드는 실행조차 되지 않습니다.
컴파일 단계에서 바로 걸러집니다.
반대로 물음표가 붙은 타입에는
null을 넣을 수 있습니다.
val nickname: String? = null // 가능: String?은 null을 담을 수 있음
여기서 두 가지 새 용어를 정리하겠습니다.
null이 들어올 수 없는 타입을
Non-null 타입이라고 부릅니다.
우리말로 “널이 아닌 타입“입니다.
null이 들어올 수 있는 타입을
Nullable 타입이라고 부릅니다.
우리말로 “널이 될 수 있는 타입“입니다.
물음표가 없으면 Non-null,
물음표가 있으면 Nullable.
이 물음표 하나가
코틀린 Null Safety의 출발점입니다.
Kotlin이 null을 타입으로 구분하는 이유
왜 굳이 타입을 두 종류로 나눌까요?
이유는 “실수를 미리 막기 위해서“입니다.
앞에서 본 자바의 문제를 다시 떠올려 봅시다.
자바에서는 모든 타입에
null이 몰래 들어올 수 있었습니다.
// 자바
String name = null; // 문제없이 통과
int length = name.length(); // 실행하는 순간 NPE 발생!
이 코드는 컴파일도 잘 되고
실행도 시작됩니다.
하지만 name이 null인 상태에서length()를 부르는 순간 터집니다.
즉, 문제가 있는데도
프로그램을 돌려 보기 전까지는 알 수 없습니다.
코틀린은 이 문제를 이렇게 해결합니다.
null이 될 수 있는 값은
타입에 아예 표시해 두자.
그러면 컴파일러가
“이 값은 null일 수 있으니 조심하라“고
미리 알려 줄 수 있습니다.
예를 들어 Nullable 타입의 값을
그냥 사용하려고 하면 이렇게 됩니다.
val nickname: String? = null
val length = nickname.length // 오류: null일 수 있어서 바로 못 씀
이 코드는 실행되지 않습니다.
컴파일러가 먼저 막아 줍니다.
자바라면 실행 중에 터졌을 오류를,
코틀린은 코드를 짜는 순간 잡아 주는 것입니다.
정리하면 이렇습니다.
| 구분 | 자바 | 코틀린 |
|---|---|---|
| null 가능성 | 모든 타입에 숨어 있음 | 타입에 ?로 표시됨 |
| 오류 발견 시점 | 실행 중 (사용자가 겪음) | 컴파일 중 (개발자가 미리 봄) |
| NPE 위험 | 항상 있음 | 크게 줄어듦 |
물론 코틀린에서도 NPE가
완전히 사라진 것은 아닙니다.
하지만 “실수로 터지는” 대부분의 NPE는
이 물음표 규칙 하나로 막을 수 있습니다.
그럼 이제 궁금해집니다.
Nullable 타입의 값은
대체 어떻게 안전하게 써야 할까요?
바로 그 방법들을
이 장의 나머지에서 하나씩 배웁니다.
5.2 Safe Call ?.
null이 아닐 때만 실행하기
Nullable 타입의 값은
그냥 점(.)을 찍어서 쓸 수 없다고 했습니다.
val nickname: String? = "길동"
val length = nickname.length // 오류: null일 수 있음
그러면 어떻게 해야 할까요?
가장 먼저 배울 도구가 Safe Call입니다.
Safe Call은 “안전한 호출“이라는 뜻이고,
물음표와 점을 붙인 ?. 모양입니다.
val nickname: String? = "길동"
val length = nickname?.length
?.의 동작은 아주 간단합니다.
값이 null이 아니면 점 뒤의 코드를 실행하고,
값이 null이면 그냥 null을 돌려준다.
즉, “null이면 아무 일도 하지 않는” 것입니다.
NPE를 낼 위험 자체가 사라집니다.
두 가지 경우를 나란히 보겠습니다.
val a: String? = "길동"
val b: String? = null
println(a?.length) // 2 (null이 아니므로 length 실행)
println(b?.length) // null (null이므로 실행하지 않고 null 반환)
여기서 중요한 점이 하나 있습니다.a?.length의 결과 타입은 Int?입니다.
값이 있을 때는 Int가 나오지만,
null일 때는 null이 나올 수 있기 때문입니다.
Safe Call의 결과는
항상 Nullable 타입이 된다.
비유하자면 ?.는
“조심스럽게 문을 두드리는 것“과 같습니다.
방에 사람이 있으면(=null이 아니면) 말을 걸고,
방이 비어 있으면(=null이면) 조용히 돌아섭니다.
문이 비어 있는데
억지로 들어가 소리치는 일(=NPE)이
일어나지 않습니다.
연속 Safe Call
?.는 여러 번 이어서 쓸 수 있습니다.
이것을 “연속 Safe Call“이라고 부릅니다.
실무에서는 객체 안에 또 객체가 들어 있는
“중첩된 구조“를 자주 다룹니다.
예를 들어 이런 상황을 생각해 봅시다.
- 회원(user)이 있고
- 회원 안에 주소(address)가 있고
- 주소 안에 도시(city)가 있다
이때 각 단계가 모두
null일 수 있다고 해 봅시다.
(아래 코드의 ?가 붙은 타입들이
“없을 수도 있다“는 뜻입니다.)
class User(val address: Address?)
class Address(val city: String?)
val user: User? = User(Address("서울"))
여기서 도시 이름을 꺼내려면
세 단계를 모두 통과해야 합니다.
자바라면 단계마다
null인지 일일이 확인해야 했습니다.
// 자바: 계단처럼 깊어지는 null 검사
String city = null;
if (user != null) {
if (user.getAddress() != null) {
city = user.getAddress().getCity();
}
}
코틀린은 이것을 한 줄로 끝냅니다.
val city: String? = user?.address?.city
println(city) // 서울
?.를 이어서 쓰면
중간 어느 하나라도 null인 순간
전체가 null이 됩니다.
val user: User? = User(Address(null)) // 도시가 null
val city = user?.address?.city
println(city) // null (마지막 city가 null이므로)
val user: User? = null // 회원 자체가 null
val city = user?.address?.city
println(city) // null (첫 단계에서 이미 null)
즉, 연속 Safe Call은
“체인 어딘가에서 끊기면 그냥 null“이라고
이해하면 됩니다.
하나라도 null이면,
끝까지 가지 않고 null을 돌려준다.
깊은 구조를 안전하게 파고들 때
아주 강력한 도구입니다.
5.3 Elvis Operator ?:
기본값 처리
Safe Call은 편리하지만
한 가지 아쉬운 점이 있습니다.
결과가 null일 수 있다는 것입니다.
때로는 “null 대신 기본값“을 쓰고 싶습니다.
예를 들어 별명이 없으면
“손님“이라고 표시하고 싶을 수 있습니다.
이때 쓰는 도구가 Elvis 연산자입니다.
물음표와 콜론을 붙인 ?: 모양입니다.
이름이 특이하지요??:를 옆으로 눕혀서 보면
엘비스 프레슬리의 앞머리와 눈을 닮았다고 해서
붙은 별명입니다.
동작은 이렇습니다.
왼쪽 값이 null이 아니면 그 값을 쓰고,
null이면 오른쪽 값을 대신 쓴다.
코드로 보겠습니다.
val nickname: String? = null
val display = nickname ?: "손님"
println(display) // 손님
nickname이 null이므로
오른쪽의 "손님"이 대신 쓰였습니다.
값이 있을 때는 원래 값을 씁니다.
val nickname: String? = "길동"
val display = nickname ?: "손님"
println(display) // 길동
Safe Call과 함께 쓰면 더 유용합니다.
둘을 이어 쓰는 패턴이 아주 흔합니다.
val nickname: String? = null
val length = nickname?.length ?: 0
println(length) // 0
이 한 줄의 뜻을 풀어 보면 이렇습니다.
nickname이 null이 아니면 그 길이를,- null이면
0을 쓴다.
결과 타입도 눈여겨봅시다.nickname?.length는 Int?였지만,?: 0을 붙이면 결과는 Int가 됩니다.
null이 될 가능성이0이라는 기본값으로 메워졌기 때문입니다.
Elvis 연산자는
Nullable을 Non-null로 바꾸는 다리 역할을 한다.
자바의 삼항 연산자와 비교하면 이렇습니다.
// 자바
String display = (nickname != null) ? nickname : "손님";
// 코틀린
val display = nickname ?: "손님"
같은 일을
훨씬 짧고 읽기 쉽게 표현합니다.
null이면 예외 발생시키기
기본값을 주는 대신,
“null이면 아예 오류를 내고 멈추는” 방법도 있습니다.
값이 없으면 더 진행하는 것이 의미 없을 때
이렇게 처리합니다.
Elvis 연산자의 오른쪽에throw를 적으면 됩니다.
val nickname: String? = null
val display = nickname ?: throw IllegalArgumentException("별명이 없습니다")
throw는 “예외를 던진다“는 뜻입니다.
프로그램을 그 자리에서 멈추고
오류를 알리는 명령입니다.
이 코드의 뜻은 이렇습니다.
nickname이 있으면 그 값을 쓰고,- null이면 예외를 던지며 멈춘다.
백엔드에서 아주 자주 쓰는 패턴입니다.
예를 들어 회원 조회는 이렇게 씁니다.
fun findUser(id: Long): User? {
// 회원을 찾지 못하면 null을 돌려준다고 가정
return null
}
fun getUserOrThrow(id: Long): User {
return findUser(id)
?: throw NoSuchElementException("회원을 찾을 수 없습니다: $id")
}
getUserOrThrow의 반환 타입은User?가 아니라 User입니다.
null인 경우를 예외로 걸러 냈기 때문에,
이 함수를 쓰는 쪽에서는
null을 걱정할 필요가 없습니다.
“값이 없으면 여기서 확실히 멈춘다“고
선을 그어 주는 방식입니다.
이렇게 하면 null이
프로그램 깊숙한 곳까지 흘러 들어가는 것을 막습니다.
예외에 관한 자세한 내용은
8부에서 따로 다룹니다.
5.4 Not-null Assertion !!
!!의 동작
지금까지는 null을 “안전하게” 다루는
방법을 배웠습니다.
이번에는 정반대의 도구를 소개합니다.
Not-null Assertion, 느낌표 두 개(!!)입니다.
Not-null Assertion은
우리말로 “널이 아님 단언“입니다.
단언(assertion)이란
“틀림없이 그렇다고 확신하며 말하는 것“입니다.
!!의 동작은 이렇습니다.
“이 값은 절대 null이 아니다“라고
개발자가 컴파일러에게 강하게 우기는 것.
코드로 보겠습니다.
val nickname: String? = "길동"
val length = nickname!!.length
println(length) // 2
nickname!!은
“이 값은 null이 아니니 그냥 쓰게 해 줘“라는 뜻입니다.
그 결과 nickname!!의 타입은String?이 아니라 String이 됩니다.
Nullable을 강제로 Non-null로
바꿔 버리는 것입니다.
언제 위험한가
문제는 이 “우기기“가 틀렸을 때입니다.
만약 값이 실제로 null이라면,!!는 그 자리에서 NPE를 터뜨립니다.
val nickname: String? = null
val length = nickname!!.length // 실행 순간 NPE 발생!
실행하면 이런 오류가 납니다.
Exception in thread "main" kotlin.KotlinNullPointerException
여기서 아주 중요한 점이 있습니다.
!!를 쓰는 순간,
코틀린이 애써 막아 주던 NPE가 다시 돌아온다.
즉, !!는
코틀린의 Null Safety를
스스로 꺼 버리는 스위치와 같습니다.
비유하자면 이렇습니다.
안전벨트를 일부러 풀고
“나는 사고 안 난다“고 우기는 것과 같습니다.
대부분의 경우!!는 좋지 않은 신호입니다.
코드에 !!가 많다면,
그만큼 NPE가 터질 구멍이 많다는 뜻입니다.
!!를 피하는 방법
그렇다면 !! 대신
무엇을 쓰면 될까요?
거의 모든 경우,
앞에서 배운 도구로 바꿀 수 있습니다.
먼저 Safe Call로 바꾸는 방법입니다.
// 위험한 방식
val length = nickname!!.length
// 안전한 방식
val length = nickname?.length ?: 0
null이면 터지는 대신0이라는 기본값을 쓰게 됩니다.
값이 반드시 있어야 하는 상황이라면,
의미 있는 예외로 바꾸는 것이 좋습니다.
// 무엇이 문제인지 알 수 없는 방식
val user = findUser(id)!!
// 원인을 알려 주는 방식
val user = findUser(id)
?: throw NoSuchElementException("회원을 찾을 수 없습니다: $id")
두 방식 모두 값이 없으면 멈추지만,
아래쪽은 “왜 멈췄는지“를 알려 줍니다.
!!가 낸 NPE는
“어디선가 null이었다“는 것 말고는
아무것도 알려 주지 않습니다.
정리하면 이렇습니다.
| 상황 | !! 대신 이렇게 |
|---|---|
| null이면 기본값을 쓰고 싶다 | ?: 사용 |
| null이면 멈춰야 한다 | ?: throw 사용 |
| null이면 그냥 넘어가도 된다 | ?. 사용 |
!!가 손에 닿을 때마다,
“정말 다른 방법이 없나?“를 먼저 떠올려 보세요.
그래도 !!가 꼭 필요한 순간이
아주 가끔 있기는 합니다.
“내가 방금 확인했으니 이 값은 확실히 있다“는
것이 코드로 증명되는 경우입니다.
하지만 초보 단계에서는!!를 쓰지 않는다고 생각하는 편이
훨씬 안전합니다.
5.5 Safe Cast as?
타입 캐스팅
이번에는 조금 다른 상황입니다.
“타입을 바꾸는” 이야기입니다.
어떤 값이 Any 타입으로 들어왔다고 해 봅시다.
(Any는 2장에서 배운, 무엇이든 담는 타입입니다.)
이 값을 문자열로 다루고 싶다면
“이건 문자열이야“라고 알려 줘야 합니다.
이렇게 값의 타입을 바꾸는 것을
타입 캐스팅(type casting)이라고 합니다.
우리말로 “형 변환“입니다.
코틀린에서 캐스팅은as 키워드로 합니다.
val value: Any = "안녕하세요"
val text = value as String
println(text.length) // 5
value as String은
“이 값을 String으로 다루겠다“는 뜻입니다.
그런데 여기에 위험이 숨어 있습니다.
만약 실제 타입이 문자열이 아니라면 어떻게 될까요?
val value: Any = 123 // 실제로는 숫자
val text = value as String // 오류 발생!
실행하면 이런 오류가 납니다.
Exception in thread "main" java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String
ClassCastException은
“클래스 형 변환 예외“입니다.
“숫자를 문자열이라고 우겼다가
들통난 것“이라고 보면 됩니다.
이런 캐스팅을
“강제 캐스팅“이라고 부릅니다.
틀리면 예외로 터진다는 점에서!!와 비슷한 위험을 가집니다.
실패를 null로 처리하기
이 위험을 안전하게 바꾸는 도구가
Safe Cast입니다.
Safe Cast는 “안전한 형 변환“이고,as? 모양입니다.
동작은 이렇습니다.
캐스팅에 성공하면 바뀐 값을 돌려주고,
실패하면 예외 대신 null을 돌려준다.
코드로 보겠습니다.
val value: Any = 123
val text = value as? String
println(text) // null (문자열이 아니므로 실패 → null)
예외로 터지지 않고
조용히 null이 나옵니다.
성공하는 경우도 봅시다.
val value: Any = "안녕하세요"
val text = value as? String
println(text) // 안녕하세요
as?의 결과는
항상 Nullable 타입이 됩니다.
실패하면 null이 나올 수 있기 때문입니다.
그래서 앞에서 배운 도구들과
자연스럽게 이어집니다.
예를 들어 Elvis 연산자와 함께 쓰면
“캐스팅 실패 시 기본값“을 줄 수 있습니다.
val value: Any = 123
val text = value as? String ?: "문자열이 아님"
println(text) // 문자열이 아님
정리하면 이렇습니다.
| 방식 | 성공할 때 | 실패할 때 |
|---|---|---|
as | 값 반환 | 예외 발생 (ClassCastException) |
as? | 값 반환 | null 반환 |
타입이 확실하지 않다면
as보다as?가 안전합니다.
특히 외부에서 들어온 데이터처럼
타입을 100% 믿을 수 없을 때as?가 든든한 안전장치가 됩니다.
5.6 Nullable 값을 다루는 함수
지금까지는 연산자(?., ?:, !!, as?)를
살펴봤습니다.
이번에는 Nullable 값을 다룰 때
자주 쓰는 함수 몇 가지를 배웁니다.
이 함수들을
범위 지정 함수(scope function)라고 부르기도 합니다.
자세한 내용은 나중에 더 다루지만,
여기서는 null 처리에 초점을 맞춥니다.
let
let은 “이 값을 가지고 무언가를 해라“라는
함수입니다.
값을 하나 받아서
그 값을 it이라는 이름으로 쓸 수 있게 해 줍니다.
먼저 기본 모양을 봅시다.
val name = "길동"
name.let {
println("이름은 $it 입니다") // it = name
}
// 출력: 이름은 길동 입니다
여기서 it은
“방금 넘어온 값“을 가리키는 기본 이름입니다.
let이 null 처리에서 빛나는 이유는
Safe Call과 함께 쓸 때입니다.
val nickname: String? = "길동"
nickname?.let {
println("별명 길이: ${it.length}")
}
?.let { }의 뜻은 이렇습니다.
값이 null이 아닐 때만
중괄호 안의 코드를 실행한다.
즉, “null이 아닐 때만 실행하는 블록“을
만들어 주는 것입니다.
null인 경우에는
블록이 아예 실행되지 않습니다.
val nickname: String? = null
nickname?.let {
println("이 줄은 실행되지 않습니다")
}
// 아무것도 출력되지 않음
게다가 블록 안에서는it이 Non-null 타입입니다.
?.let을 통과했다는 것은
이미 null이 아님이 확인됐다는 뜻이기 때문입니다.
val nickname: String? = "길동"
nickname?.let {
// 이 안에서 it은 String (String?이 아님)
val upper = it.uppercase() // 안전하게 사용 가능
println(upper)
}
정리하면 이렇습니다.
값?.let { ... }은
“값이 있을 때만, 그 값으로 무언가 하기“입니다.
takeIf
takeIf는 “조건에 맞으면 그 값을,
아니면 null을 돌려주는” 함수입니다.
동작은 이렇습니다.
조건이 참이면 원래 값을 돌려주고,
조건이 거짓이면 null을 돌려준다.
코드로 보겠습니다.
val age = 20
val adult = age.takeIf { it >= 19 }
println(adult) // 20 (조건이 참이므로 값 그대로)
val age = 15
val adult = age.takeIf { it >= 19 }
println(adult) // null (조건이 거짓이므로 null)
takeIf는
“조건을 통과한 값만 남기고 싶을 때” 유용합니다.
Elvis 연산자와 함께 쓰면
“조건에 맞지 않으면 기본값“도 만들 수 있습니다.
val input = " " // 공백만 있는 문자열
val name = input.takeIf { it.isNotBlank() } ?: "이름 없음"
println(name) // 이름 없음
이 코드의 뜻은 이렇습니다.
- 입력값이 공백이 아니면 그 값을,
- 공백이면 “이름 없음“을 쓴다.
isNotBlank()는
“공백만 있는 게 아닌지“를 확인하는 함수입니다.
takeUnless
takeUnless는takeIf와 정반대입니다.
동작은 이렇습니다.
조건이 거짓이면 원래 값을 돌려주고,
조건이 참이면 null을 돌려준다.
이름 그대로
“unless(~가 아니라면)“의 느낌입니다.
val age = 15
val minor = age.takeUnless { it >= 19 }
println(minor) // 15 (19 이상이 아니므로 값 그대로)
val age = 20
val minor = age.takeUnless { it >= 19 }
println(minor) // null (19 이상이므로 null)
takeIf와 takeUnless는
같은 조건을 반대로 읽는 것뿐입니다.
어느 쪽이 더 읽기 자연스러운지에 따라
골라 쓰면 됩니다.
세 함수를 한눈에 정리하면 이렇습니다.
| 함수 | 하는 일 |
|---|---|
let | 값이 있을 때만, 그 값으로 코드 실행 |
takeIf | 조건이 참이면 값, 거짓이면 null |
takeUnless | 조건이 거짓이면 값, 참이면 null |
이 함수들은?., ?:와 조합할 때 특히 강력합니다.
null 처리 흐름을
한 줄로 깔끔하게 표현할 수 있게 해 줍니다.
5.7 초기화를 늦추기
값을 나중에 넣고 싶을 때
가끔 이런 상황이 있습니다.
변수를 선언할 때는
아직 넣을 값이 없지만,
나중에는 반드시 값이 채워지는 경우입니다.
예를 들어 스프링 같은 프레임워크가
값을 대신 넣어 주는 상황이 그렇습니다.
(이런 것은 나중 부에서 배웁니다.)
이럴 때 억지로 Nullable로 만들면
불편해집니다.
class UserService {
var repository: Repository? = null // 매번 null 검사를 해야 함
}
값을 쓸 때마다?.나 !!를 붙여야 하기 때문입니다.
“나중에 채워지지만, 채워진 뒤에는
절대 null이 아닌” 값을 위해
코틀린은 두 가지 방법을 준비했습니다.
바로 lateinit과 lazy입니다.
lateinit
lateinit은
“늦은 초기화(late initialization)“의 줄임말입니다.
초기화(initialization)란
변수에 처음으로 값을 넣는 일입니다.
lateinit은
“지금은 값을 안 넣지만,
곧 넣을 테니 기다려 달라“는 표시입니다.
class UserService {
lateinit var repository: Repository
fun setup() {
repository = Repository() // 나중에 값을 넣음
}
fun use() {
repository.findAll() // ?. 없이 바로 사용
}
}
lateinit을 쓰면
타입에 ?를 붙이지 않아도 됩니다.
값을 쓸 때도?.나 !! 없이 그냥 쓸 수 있습니다.
몇 가지 규칙이 있습니다.
var에만 쓸 수 있다 (val에는 못 씀)- 기본 타입(Int, Boolean 등)에는 못 쓴다
- 값을 넣기 전에 쓰면 예외가 난다
특히 마지막 규칙을 조심해야 합니다.
class UserService {
lateinit var repository: Repository
fun use() {
repository.findAll() // 값을 안 넣고 쓰면 예외 발생!
}
}
값을 넣기 전에 쓰면
이런 오류가 납니다.
kotlin.UninitializedPropertyAccessException:
lateinit property repository has not been initialized
“아직 초기화되지 않았다“는 뜻입니다.
값이 들어왔는지 궁금할 때는::변수명.isInitialized로 확인할 수 있습니다.
if (::repository.isInitialized) {
repository.findAll()
}
lazy
lazy는
“게으른(lazy) 초기화“입니다.
동작은 이렇습니다.
값을 미리 만들지 않고,
처음 사용하는 순간에 딱 한 번 만든다.
lazy는 val과 함께 씁니다.by lazy { } 모양으로 적습니다.
class Config {
val settings: String by lazy {
println("설정을 불러옵니다")
"설정값"
}
}
fun main() {
val config = Config()
println("아직 안 씀")
println(config.settings) // 이때 처음 만들어짐
println(config.settings) // 이미 만들어진 값을 재사용
}
실행 결과는 이렇습니다.
아직 안 씀
설정을 불러옵니다
설정값
설정값
눈여겨볼 점이 두 가지입니다.
먼저, “설정을 불러옵니다“가config.settings를 처음 쓸 때 나옵니다.
객체를 만들 때가 아니라,
값을 실제로 쓰는 순간에
만들어지기 때문입니다.
다음으로, 그 메시지가 한 번만 나옵니다.
두 번째부터는 이미 만든 값을
그대로 다시 쓰기 때문입니다.
값을 만드는 데 시간이 오래 걸린다면,lazy로 “필요할 때만” 만들게 하면
프로그램이 더 빨라질 수 있습니다.
두 방식의 차이
lateinit과 lazy는
둘 다 초기화를 미루지만
쓰임새가 다릅니다.
| 구분 | lateinit | lazy |
|---|---|---|
| 대상 | var | val |
| 값 넣는 시점 | 내가 직접, 원할 때 넣음 | 처음 쓸 때 자동으로 만들어짐 |
| 값 넣는 방법 | 변수 = 값 으로 대입 | by lazy { } 안에서 계산 |
| 기본 타입 가능? | 불가능 | 가능 |
| 어울리는 상황 | 외부가 값을 넣어 주는 경우 | 무거운 값을 필요할 때 만드는 경우 |
간단한 기준은 이렇습니다.
내가 직접 값을 넣어 줘야 하면
lateinit,
처음 쓸 때 알아서 만들어지면 좋겠으면lazy.
둘 다 Nullable을 쓰지 않고도
“나중에 채워지는 값“을
안전하게 다루게 해 줍니다.
5.8 Collection과 Null
물음표의 위치가 중요하다
컬렉션(collection)은
“값을 여러 개 담는 그릇“입니다.
(리스트 등은 7부에서 자세히 다룹니다.)
여기서는 컬렉션과 null이 만날 때
헷갈리기 쉬운 부분만 짚겠습니다.
리스트에 ?를 붙이는 위치에 따라
뜻이 완전히 달라집니다.
세 가지 경우를 봅시다.
val a: List<String?> // 리스트 안의 요소가 null일 수 있음
val b: List<String>? // 리스트 자체가 null일 수 있음
val c: List<String?>? // 요소도, 리스트도 null일 수 있음
하나씩 뜯어보겠습니다.
List<String?>은
“리스트는 반드시 있지만,
그 안의 값들은 null일 수 있다“는 뜻입니다.
val names: List<String?> = listOf("길동", null, "순신")
// 리스트는 있고, 요소 중 하나가 null
List<String>?은
“리스트 안의 값들은 모두 있지만,
리스트 자체가 없을 수 있다“는 뜻입니다.
val names: List<String>? = null
// 리스트 자체가 null
List<String?>?은
“둘 다 null일 수 있다“는 뜻입니다.
val names: List<String?>? = null
// 리스트도 null일 수 있고, 요소도 null일 수 있음
표로 정리하면 한눈에 들어옵니다.
| 타입 | 리스트 자체 | 안의 요소 |
|---|---|---|
List<String> | 있음 | 있음 |
List<String?> | 있음 | null 가능 |
List<String>? | null 가능 | 있음 |
List<String?>? | null 가능 | null 가능 |
?가 꺾쇠(< >) 안에 있으면 요소,
꺾쇠 밖에 있으면 리스트 자체.
이 위치 감각을 익혀 두면
복잡한 타입도 차분히 읽을 수 있습니다.
filterNotNull
리스트 안에 null이 섞여 있을 때,
null만 걸러 내고 싶을 때가 많습니다.
이때 쓰는 함수가filterNotNull입니다.
이름 그대로
“null이 아닌 것만 걸러 낸다“는 뜻입니다.
val names: List<String?> = listOf("길동", null, "순신", null)
val clean = names.filterNotNull()
println(clean) // [길동, 순신]
여기서 아주 중요한 점이 있습니다.
결과의 타입이 바뀝니다.
names는 List<String?>였지만,filterNotNull()을 거친 clean은List<String>입니다.
null을 걸러 냈으므로
더 이상 요소가 null일 수 없기 때문입니다.
val clean: List<String> = names.filterNotNull()
for (name in clean) {
println(name.length) // ?. 없이 안전하게 사용
}
null을 제거한 뒤에는?.나 !! 없이
바로 쓸 수 있게 되는 것입니다.
mapNotNull
mapNotNull은 조금 더 똑똑합니다.
“변환과 null 걸러 내기를 한 번에” 합니다.
map은 각 요소를
다른 값으로 바꾸는 함수입니다.
(자세한 내용은 7부에서 배웁니다.)
mapNotNull은 여기에
“변환 결과가 null이면 버린다“를 더한 것입니다.
예를 들어 문자열 리스트를
숫자로 바꾸는 상황을 봅시다.
val inputs = listOf("1", "둘", "3", "네", "5")
val numbers = inputs.mapNotNull { it.toIntOrNull() }
println(numbers) // [1, 3, 5]
toIntOrNull()은
“숫자로 바꿀 수 있으면 숫자를,
없으면 null을 돌려주는” 함수입니다.
"1"→1"둘"→ null (숫자가 아님 → 버려짐)"3"→3"네"→ null (버려짐)"5"→5
그 결과 숫자로 바꿀 수 있는 것만
깔끔하게 남았습니다.
numbers의 타입도List<Int>입니다. (null이 없음)
filterNotNull과 mapNotNull을
비교하면 이렇습니다.
| 함수 | 하는 일 |
|---|---|
filterNotNull | 이미 있는 null 요소를 걸러 냄 |
mapNotNull | 값을 변환하면서, 결과가 null이면 걸러 냄 |
두 함수 모두
“null이 없는 깨끗한 리스트“를
만들어 준다는 공통점이 있습니다.
컬렉션을 다룰 때
아주 자주 쓰게 될 도구들입니다.
5장을 마치며
이 장에서 우리는
코틀린의 핵심인 Null Safety를 배웠습니다.
- null이 될 수 있는 타입은
?로 표시하고,
코틀린은 이를 타입 단계에서 구분한다는 점 - Safe Call(
?.)로
null이 아닐 때만 안전하게 실행하는 법 - Elvis 연산자(
?:)로
기본값을 주거나 예외를 던지는 법 - Not-null Assertion(
!!)의 위험과
이를 피하는 방법 - Safe Cast(
as?)로
캐스팅 실패를 null로 안전하게 다루는 법 let,takeIf,takeUnless로
Nullable 값을 흐름 속에서 다루는 법lateinit과lazy로
초기화를 안전하게 늦추는 두 가지 방법- 컬렉션에서
?의 위치가 갖는 의미와,filterNotNull,mapNotNull로 null을 걸러 내는 법
가장 중요한 습관은 하나입니다.
null을 만나면
!!로 우기지 말고,?.와?:로 안전하게 흘려보내자.
이 습관만 몸에 배어도
서버에서 가장 자주 터지는 오류를
크게 줄일 수 있습니다.
한 가지 덧붙일 것이 있습니다.
자바 코드를 코틀린에서 불러 쓸 때는,
코틀린이 null 여부를 확신하지 못하는
“플랫폼 타입(platform type)“이 등장합니다.
이 내용은 바로 다음 6장에서
이어서 다룹니다.
null을 두려워하지 않고
다룰 수 있게 되었으니,
이제 자바와 코틀린이 만나는 지점으로
넘어가 봅시다.